Distinct epitopes assumption¶
The Polyclonal model assumes that a polyclonal antibody mix can be divided into independent groups of neutralizing antibodies that bind to distinct epitopes without competition. Here we interrogate the validity of this assumption, being cognizant of the observation that realistic epitopes are often overlapping and therefore not distinct. To do this, we draw from statistical mechanics principles to compare the antibody escape fractions predicted by Polyclonal and an identically formulated
model that instead assumes all epitopes are overlapping.
1. Modeling a monoclonal antibody that neutralizes a viral protein¶
Before we consider the polyclonal antibody case, lets first consider the case of a monoclonal antibody that neutralizes a viral protein. Here, the viral protein can exist in two microstates, bound or unbound by a neutralizing antibody. The Boltzmann weight is 1 for the unbound state and \(\frac{c}{K_d}\) for the bound state, where \(c\) is the antibody concentration and \(K_d\) is the dissociation constant of antibody-protein binding. These Boltzmann weights can derived using the steady-state approximation (see Einav et al. 2020).
We can then define the partition function \(\Xi\) as:
where \(Z_{i}\) represents the Boltzmann weights of the \(i\) microstates. Thus, the probability of a viral protein being unbound by a neutralizing antibody, or in other words the escape fraction, is:
2. Modeling polyclonal antibodies that neutralize a viral protein¶
To extend above to a polyclonal antibody mix, we first modify \(c\) to represent the concentration of the polyclonal antibody mix. We assume that the polyclonal antibody mix contains neutralizing antibodies that bind one of \(E\) epitopes. As follows, the Boltzmann weight of the state where epitope \(e\) is bound is modified to \(\frac{c f_e}{K_{d,e}}\), where \(f_e\) represents the fraction of neutralizing antibodies in the polyclonal mix that target epitope \(e\), and \(K_{d,e}\) is the dissociation constant of neutralizing antibodies binding to epitope \(e\).
2.1 Two distinct epitopes¶
In a polyclonal antibody mix, it now becomes possible for new microstates to exist where multiple epitopes are bound by antibodies. For example, we can consider a viral protein that contains two distinct epitopes (1 and 2) that are targeted by polyclonal antibodies. In addition to the microstates where a single epitope is bound, we now require an additional microstate where both epitopes are bound. The Boltzmann weight for this new microstate is:
Thus, we can rewrite the partition function \(\Xi\) as:
and the probability of a viral protein being unbound by neutralizing antibodies is:
Note that Eq. 2 exactly corresponds to the Polyclonal model.
2.2 Two overlapping epitopes¶
In the above case, the two epitopes were distinct and there was no competition amongst neutralizing antibodies. However, if the epitopes are overlapping and there is competition, then the microstate where both epitopes are bound (\(Z_{12,bound}\)) can no longer happen. In this case, the probability of a viral protein being unbound by antibodies is:
We can define functions to compute \(p_{unbound}\) with Eq. 2 or Eq. 3.
[1]:
import numpy as np
def overlapping_epitopes(K_d, c, f):
"""
Calculate `p_unbound` under the overlapping epitopes assumption.
Args ---
K_d : numpy.ndarray
dissociation constants for antibodies targeting each epitope.
c : float
concentration of polyclonal antibody mix.
f : numpy.ndarray
fractions of antibodies targeting each epitope.
"""
if np.sum(f) != 1:
raise ValueError("The fractions in `f` do not add up to 1.")
boltzmann_weights = c * f / K_d
part_fx = 1 + np.sum(boltzmann_weights)
p_unbound = 1 / part_fx
return p_unbound
def distinct_epitopes(K_d, c, f):
"""
Calculate `p_unbound` under the distinct epitopes assumption.
Args ---
K_d : numpy.ndarray
dissociation constants for antibodies targeting each epitope.
c : float
concentration of polyclonal antibody mix.
f : numpy.ndarray
fractions of antibodies targeting each epitope.
"""
if np.sum(f) != 1:
raise ValueError("The fractions in `f` do not add up to 1.")
p_unbound = np.prod(1 / (1 + (c * f / K_d)))
return p_unbound
Next, we can see how the predicted \(p_{unbound}\) varies as a function of \(c\), \(f_{1}\), \(f_{2}\), \(K_{d,1}\), and \(K_{d,2}\) under both the distinct and overlapping epitopes assumptions. You can toggle the various parameters to visualize the resulting curves and hover your cursor over the plot to see the differences in predicted \(p_{unbound}\).
[2]:
# NBVAL_IGNORE_OUTPUT
import itertools
import altair as alt
import pandas as pd
# Calculate escape fraction under the overlapping and distinct epitopes assumptions
# for a range of sera concentrations, antibody dissociation constants, and relative
# antibody fractions.
c_range = np.logspace(-10, -1, 50)
Kds = np.logspace(-10, -1, 10)
fracs = np.around(np.linspace(0, 1, 11), decimals=1)
fracs_combos = np.array(list(itertools.product(fracs, fracs)))
Ab_fracs = fracs_combos[np.where(np.sum(fracs_combos, axis=1) == 1)]
Ab_Kds = np.array(list(itertools.product(Kds, Kds)))
p_unbound_distinct = []
p_unbound_overlap = []
Ab_f1 = []
Ab_f2 = []
Ab_Kd1 = []
Ab_Kd2 = []
concs = []
for c in c_range:
for f in Ab_fracs:
for k in Ab_Kds:
p_unbound_distinct.append(distinct_epitopes(k, c, f).round(3))
p_unbound_overlap.append(overlapping_epitopes(k, c, f).round(3))
Ab_f1.append(f[0])
Ab_f2.append(f[1])
Ab_Kd1.append(np.log10(k[0]))
Ab_Kd2.append(np.log10(k[1]))
concs.append(c)
df = pd.DataFrame(
data={
"Concentration": concs,
"epitope_distinct": p_unbound_distinct,
"epitope_overlap": p_unbound_overlap,
"Ab_fraction_1": Ab_f1,
"Ab_fraction_2": Ab_f2,
"Ab_Kd_1": Ab_Kd1,
"Ab_Kd_2": Ab_Kd2,
}
).melt(
id_vars=["Concentration", "Ab_fraction_1", "Ab_fraction_2", "Ab_Kd_1", "Ab_Kd_2"],
value_name="Escape fraction",
var_name="Model assumption",
)
alt.data_transformers.disable_max_rows()
# sliders to toggle
slider_Ab1 = alt.binding_range(min=0, max=1, step=0.1, name="Antibody 1 fraction:")
Ab_1_selector = alt.selection_single(
fields=["Ab_fraction_1"], bind=slider_Ab1, init={"Ab_fraction_1": 0.5}
)
slider_Kd1 = alt.binding_range(min=-10, max=-3, step=1, name="log Antibody 1 Kd:")
Kd_1_selector = alt.selection_single(
fields=["Ab_Kd_1"], bind=slider_Kd1, init={"Ab_Kd_1": -5}
)
slider_Kd2 = alt.binding_range(min=-10, max=-3, step=1, name="log Antibody 2 Kd:")
Kd_2_selector = alt.selection_single(
fields=["Ab_Kd_2"], bind=slider_Kd2, init={"Ab_Kd_2": -5}
)
# Create a selection that chooses the nearest point & selects based on x-value
nearest = alt.selection(
type="single", nearest=True, on="mouseover", fields=["Concentration"], empty="none"
)
# The basic line
line = (
alt.Chart(df)
.mark_line(interpolate="basis")
.encode(x="Concentration:Q", y="Escape fraction:Q", color="Model assumption")
.transform_filter(Ab_1_selector)
.transform_filter(Kd_1_selector)
.transform_filter(Kd_2_selector)
)
# Transparent selectors across the chart. This is what tells us
# the x-value of the cursor
selectors = (
alt.Chart(df)
.mark_point()
.encode(
x="Concentration:Q",
opacity=alt.value(0),
)
.add_selection(nearest)
)
# Draw text labels near the points, and highlight based on selection
text = (
line.mark_text(align="left", dx=5, dy=0)
.encode(text=alt.condition(nearest, "Escape fraction:Q", alt.value(" ")))
.transform_filter(Ab_1_selector)
.transform_filter(Kd_1_selector)
.transform_filter(Kd_2_selector)
)
# Draw a rule at the location of the selection
rules = (
alt.Chart(df)
.mark_rule(color="gray")
.encode(
x="Concentration:Q",
)
.transform_filter(nearest)
.transform_filter(Ab_1_selector)
.transform_filter(Kd_1_selector)
.transform_filter(Kd_2_selector)
)
# The curve itself
curve = (
alt.Chart(df)
.mark_line()
.encode(
x=alt.X(
"Concentration:Q", scale=alt.Scale(type="log"), axis=alt.Axis(tickCount=5)
),
y="Escape fraction:Q",
color=alt.Color(
"Model assumption",
scale=alt.Scale(
domain=["epitope_distinct", "epitope_overlap"],
range=["#ec5094", "#5c7594"],
),
),
)
.add_selection(Ab_1_selector, Kd_1_selector, Kd_2_selector)
.transform_filter(Ab_1_selector)
.transform_filter(Kd_1_selector)
.transform_filter(Kd_2_selector)
)
# Draw points on the line, and highlight based on selection
points = (
line.mark_point()
.encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0)))
.transform_filter(Ab_1_selector)
.transform_filter(Kd_1_selector)
.transform_filter(Kd_2_selector)
)
# Put the layers into a chart and bind the data
alt.layer(line, selectors, curve, points, rules, text).properties(
width=500, height=300, title="2 epitopes"
)
[2]: